Skip to content

Say which shape of key would not sign a release - #226

Merged
iderex merged 1 commit into
mainfrom
release/name-the-key-shape-that-would-not-sign
Aug 30, 2026
Merged

Say which shape of key would not sign a release#226
iderex merged 1 commit into
mainfrom
release/name-the-key-shape-that-would-not-sign

Conversation

@iderex

@iderex iderex commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Refs #41.

What was wrong

The signing step writes the RELEASE_SIGNING_KEY secret to a file and hands it
to ssh-keygen -Y sign. Where ssh-keygen refuses, set -e ended the step and
the log carried ssh-keygen's own message and nothing else. Two shapes of
private key produce that refusal and neither message names its cause.

A key carrying a passphrase reports an incorrect passphrase, because a runner
has no terminal to ask at and an empty one is read instead:

Enter passphrase for ".../signing-key": Load key ".../signing-key": incorrect passphrase supplied to decrypt private key
exit=255

A key whose line endings are CRLF, which is what a private half pasted through a
Windows editor carries, reports a public key that does not exist:

Couldn't load public key .../signing-key: No such file or directory
exit=255

That second message is the expensive one. It names a second file and a missing
path, and the thing that is wrong is one byte per line in the file it was given.

Both are the secret set wrongly rather than the release built wrongly, and this
step is the only place in the run that can tell those two apart.

What the change does

It captures the exit code instead of letting set -e end the step, removes the
key file on both paths, and refuses with a message naming the two shapes.

The key file removal is a second defect rather than a tidy-up. Against the step
as the default branch has it, the same passphrase key leaves the private half
written to the runner's temporary directory, because rm -f sat after the
command that failed:

exit=255
key file left behind in RUNNER_TEMP: 1

With this change, on every one of the four cases below:

key file left behind in RUNNER_TEMP: 0

Proved by running it, not by reading it

I extracted the step's own run: block from this file and from the default
branch and ran both against throwaway keys generated for the purpose. Nothing
here touched the account key, and no key material is in this change.

the secret holds before after
an unencrypted key with LF endings signs, exit 0 signs, exit 0
a key carrying a passphrase exit 255, no annotation, key left behind exit 1, both shapes named, key removed
a key with CRLF endings exit 255, no annotation, key left behind exit 1, both shapes named, key removed
nothing at all exit 1, already named unchanged

The passing row is the one that matters most, so it was checked end to end
rather than by its exit code. The block signed a stand-in SHA256SUMS and the
signature verified with the two commands docs/release-notes-preamble.md hands
a reader:

Good "file" signature for iderex with ED25519 key SHA256:mq2QAYfDeW21wqaQRb7fp/ZOVgJMUan+FxxvDfLtxgk

The rehearsal that found it

I found this while walking the whole release path at 8d4c281, which had never
been run with a key present. Every step of release.yml and both jobs of
smoke.yml were executed against a throwaway local tag, deleted afterwards and
never pushed:

built 6 platform(s), each twice, with matching checksums
6 binaries produced one notices document and one bill of materials
SHA256SUMS covers all 11 published file(s)
signed SHA256SUMS, 294 byte(s)
v0.0.1-af05-rehearsal: checked 11 digest(s) against 11 published file(s)
v0.0.1-af05-rehearsal: the signature over SHA256SUMS verifies against 1 published key(s)
windows/amd64: executed 1 published artefact against a fresh clone; it read 27 decision record(s) and said what it examined

That is the first run in which the release side produced a signature and the
smoke side consumed the same one. The two had only ever been exercised
separately, against bytes written by hand.

The means

The change is a shell block inside a workflow file, because that is what the
thing being repaired is. No language is added and nothing new is depended on:
the step already ran ssh-keygen and already tested an exit condition, and what
changes is which condition it tests and what it says.

What this does not do

It does not finish #41. That issue is left on its first leg, a tag producing the
artefacts, and nothing in this tree reaches it.

It refuses no new class. What it repairs is a message, and the failure it
prevents is a person reading Couldn't load public key ... No such file or directory and looking for a missing file.

Nothing here runs on a pull request. Both legs of this workflow reach a tag
only, so no check on this change executes the block it edits, and the evidence
above is two shells on this machine standing in for two steps on a runner. It
reproduces the working directory and the absence of a terminal, and nothing else
about one.

The rehearsal ran on Windows and the workflow runs on ubuntu-latest. What that
bounds is the exact wording ssh-keygen prints; the exit code and the shape of
the failure are the same on both, and the message this step adds is written here
rather than read from ssh-keygen.

Reading

No second person read this change. It is one file, one step and 22 added lines,
and the evidence above stands in place of a reader rather than being offered as
equivalent to one.

Refs #41.

The signing step hands the RELEASE_SIGNING_KEY secret to ssh-keygen and, until
now, let ssh-keygen's own refusal end the step. Two shapes of private key
produce that refusal and neither of its messages names the cause: a key
carrying a passphrase reports an incorrect passphrase, having read an empty one
from a runner with no terminal to ask at, and a key whose line endings are CRLF
reports a public key that does not exist, which points at a second file rather
than at the bytes that are wrong. Both return 255, both mean the secret was set
wrongly rather than the release built wrongly, and this step is the only place
that distinction can be drawn.

I measured both with throwaway keys against the step's own block rather than
reasoning about them:

    Enter passphrase for ".../signing-key": Load key ".../signing-key":
    incorrect passphrase supplied to decrypt private key
    exit=255

    Couldn't load public key .../signing-key: No such file or directory
    exit=255

The step now captures the code, names both shapes and returns 1. It also
removes the key file on the failing path, which set -e skipped before: the same
passphrase key run against the step as the default branch has it leaves the
private half written to the runner's temporary directory.

Nothing changes for a release whose secret is right. The same block signed a
stand-in checksum file with a well-formed key and the signature verified with
the two commands docs/release-notes-preamble.md hands a reader.

Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com>
@iderex iderex self-assigned this Aug 30, 2026
@iderex
iderex merged commit f59943d into main Aug 30, 2026
25 checks passed
@iderex
iderex deleted the release/name-the-key-shape-that-would-not-sign branch August 30, 2026 11:33
This was referenced Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant